/* * Copyright (c) 2013 Google Inc. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); you * may not use this file except in compliance with the License. You may * obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or * implied. See the License for the specific language governing * permissions and limitations under the License. */ package com.google.appengine.demos.websocketchat.server; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.net.HttpURLConnection; import java.net.InetSocketAddress; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.CopyOnWriteArrayList; import java.util.logging.Logger; import com.google.appengine.api.NamespaceManager; import com.google.appengine.api.ThreadManager; import com.google.appengine.api.utils.SystemProperty; import com.google.appengine.demos.websocketchat.domain.ChatRoomParticipants; import com.google.appengine.demos.websocketchat.domain.WebSocketServerNode; import com.google.appengine.demos.websocketchat.message.ChatMessage; import com.google.appengine.demos.websocketchat.message.OutgoingMessage; import com.google.appengine.demos.websocketchat.message.ParticipantListMessage; import com.google.apphosting.api.ApiProxy; import com.google.common.base.Throwables; import com.google.gson.Gson; import com.googlecode.objectify.Key; import org.java_websocket.WebSocket; import org.java_websocket.client.WebSocketClient; import org.java_websocket.handshake.ClientHandshake; import org.java_websocket.handshake.ServerHandshake; import org.java_websocket.server.WebSocketServer; import static com.googlecode.objectify.ObjectifyService.ofy; /** * A simple WebSocketServerNode implementation. Keeps track of a "websocketchat". */ public class ChatSocketServer extends WebSocketServer { private static final Logger LOG = Logger.getLogger(ChatSocketServer.class.getName()); private static final int DEFAULT_PORT = 65080; private static final Gson GSON = new Gson(); private static final String NETWORK_INTERFACE_METADATA_URL = "http://metadata/computeMetadata/v1beta1/instance/network-interfaces/0/access-configs/0/" + "external-ip"; private final MetaInfoManager metaInfoManager; private ConcurrentLinkedQueue<String> updateAndSendParticipantListQueue; private ConcurrentLinkedQueue<OutgoingMessage> propagateQueue; private String hostname; private String getHostname() throws IOException { if (hostname == null) { if (SystemProperty.environment.value().equals(SystemProperty.Environment.Value.Production)) { URL url = new URL(NETWORK_INTERFACE_METADATA_URL); HttpURLConnection httpUrlConnection = (HttpURLConnection) url.openConnection(); BufferedReader reader = new BufferedReader( new InputStreamReader(httpUrlConnection.getInputStream())); String result, line = reader.readLine(); result = line; while ((line = reader.readLine()) != null) { result += line; } hostname = result; } else { hostname = "localhost"; } } return hostname; } /** * Returns a Websocket URL of this server. * * @return a Websocket URL of this server. * @throws IOException when failed to get the external IP address from the metadata server. */ public String getWebSocketURL() throws IOException { return "ws://" + getHostname() + ":" + this.getPort() + "/"; } /** * A class that runs in another thread and becomes a bridge between App Engine and the * websocket server. */ public static class ChatServerBridge implements Runnable { private ChatSocketServer chatSocketServer; private Thread watcherThread; private String namespace; private static ChatServerBridge chatServerBridge; private CopyOnWriteArrayList<Key<ChatRoomParticipants>> chatRoomParticipantsKeyList; private ApiProxy.Environment backgroundEnvironment; private ChatServerBridge() { namespace = NamespaceManager.get(); chatRoomParticipantsKeyList = new CopyOnWriteArrayList<>(); } /** * Returns a singleton instance of this class. * * @return a singleton instance of this class. */ public static ChatServerBridge getInstance() { if (chatServerBridge == null) { chatServerBridge = new ChatServerBridge(); } return chatServerBridge; } private void registerWebSocketServerNode() throws IOException { WebSocketServerNode webSocketServerNode = new WebSocketServerNode( chatSocketServer.getWebSocketURL()); ofy().save().entity(webSocketServerNode).now(); } private void removeWebSocketServerNode() throws IOException { Key<WebSocketServerNode> key = WebSocketServerNode.getKeyFromWebSocketUrl( chatSocketServer.getWebSocketURL()); ofy().delete().key(key).now(); } protected ApiProxy.Environment getBackgroundEnvironment() { return backgroundEnvironment; } /** * Starts the websocket server, registers necessary information and then starts the bridge * thread. */ public void start() { if (chatSocketServer != null) { throw new IllegalStateException("We already have a chatSocketServer."); } chatSocketServer = new ChatSocketServer(DEFAULT_PORT); chatSocketServer.start(); LOG.info("Server started on port: " + chatSocketServer.getPort()); try { registerWebSocketServerNode(); } catch (IOException e) { LOG.warning(Throwables.getStackTraceAsString(e)); } ThreadManager.createBackgroundThread(this).start(); } /** * Stops the websocket server, cleans up some info, and then stop the main bridge thread. */ public void stop() { try { removeWebSocketServerNode(); chatSocketServer.stop(); watcherThread.interrupt(); watcherThread.join(); watcherThread = null; // delete participant list in the datastore ofy().delete().keys(chatRoomParticipantsKeyList).now(); // initialize variables chatRoomParticipantsKeyList = new CopyOnWriteArrayList<>(); chatSocketServer = null; } catch (IOException|InterruptedException e) { LOG.warning(Throwables.getStackTraceAsString(e)); } } /** * Pops a name of a chat room from the updateAndSendParticipantListQueue and update the * participant list in the datastore, then creates the global list of the given chat room and * distribute it to the clients who is participating to that chat room. * * @throws IOException */ private void updateParticipantListAndDistribute() throws IOException { if (! chatSocketServer.updateAndSendParticipantListQueue.isEmpty()) { // Update the participant list in the datastore String room = chatSocketServer.updateAndSendParticipantListQueue.remove(); ChatRoomParticipants chatRoomParticipants = new ChatRoomParticipants(room, chatSocketServer.getWebSocketURL(), chatSocketServer.metaInfoManager.getParticipantList(room)); ofy().save().entity(chatRoomParticipants).now(); chatRoomParticipantsKeyList.add(chatRoomParticipants.getKey()); // Retrieve the full participant list in the room and distribute it Set<String> participantSet = ChatRoomParticipants.getParticipants(room); ParticipantListMessage participantListMessage = new ParticipantListMessage(room, participantSet); chatSocketServer.sendToClients(participantListMessage); } } /** * Propagate a message popped from the propagateQueue to other active server nodes. * * @throws IOException */ private void propagateOneMessage() throws IOException { if (! chatSocketServer.propagateQueue.isEmpty()) { // handle message propagation between the server nodes. OutgoingMessage message = chatSocketServer.propagateQueue.remove(); LOG.info("Handling a propagate message: " + message.toJson(GSON)); Key<WebSocketServerNode> parentKey = WebSocketServerNode.getRootKey(); List<Key<WebSocketServerNode>> serverKeys = ofy().load() .type(WebSocketServerNode.class).ancestor(parentKey).keys().list(); final ChatMessage propagateMessage = ChatMessage.createPropagateMessage(message, GSON); for (Key<WebSocketServerNode> key: serverKeys) { LOG.info("Server: " + key.getName()); if (! key.getName().equals(chatSocketServer.getWebSocketURL())) { // Send a propagate message LOG.info("Trying to send a message to the server: " + key.getName()); try { final WebSocketClient chatClient = new WebSocketClient(new URI(key.getName())) { @Override public void onOpen(ServerHandshake handshakedata) { // Send propagateMessage itself. this.send(GSON.toJson(propagateMessage)); this.close(); } @Override public void onMessage(String message) { LOG.info("Message received: " + message); } @Override public void onClose(int code, String reason, boolean remote) { LOG.info("Connection closed."); } @Override public void onError(Exception ex) { LOG.warning(Throwables.getStackTraceAsString(ex)); } }; chatClient.connect(); } catch (URISyntaxException e) { LOG.warning(Throwables.getStackTraceAsString(e)); } } } } } /** * A main loop of this bridge thread. * * <p>The chat server requests us the following 2 things.</p> * <ul> * <li>Update and distribute the participant list in a particular chat room.</li> * <li>Propagate a message to other active server nodes.</li> * </ul> * <p>This thread watches the 2 queues on the ChatSocketServer instance, * and handles those requests in the main loop.</p> * <p>If this loop becomes the performance bottleneck, distribute these work loads into * multiple thread worker.</p> */ public void run() { if (watcherThread != null) { throw new IllegalStateException("A watcherThread already exists."); } watcherThread = Thread.currentThread(); LOG.info("Namespace is set to " + namespace + " in thread " + watcherThread.toString()); NamespaceManager.set(namespace); // Store the environment for later use. backgroundEnvironment = ApiProxy.getCurrentEnvironment(); while (true) { if (Thread.currentThread().isInterrupted()) { LOG.info("ChatServerBridge is stopping."); return; } else { try { updateParticipantListAndDistribute(); propagateOneMessage(); Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } catch (IOException e) { LOG.warning(Throwables.getStackTraceAsString(e)); } } } } /** * Returns a Websocket URL of the chat server. * * @return a Websocket URL of the chat server. * @throws IOException when failed to get the external IP address from the metadata server. */ public String getWebSocketURL() throws IOException { return this.chatSocketServer.getWebSocketURL(); } } /** * Creates a ChatSoccketServer instance with the given network port. * * @param port a port number on which this chat server will listen. */ public ChatSocketServer(int port) { super(new InetSocketAddress(port)); metaInfoManager = new MetaInfoManager(); updateAndSendParticipantListQueue = new ConcurrentLinkedQueue<>(); propagateQueue = new ConcurrentLinkedQueue<>(); } /** * Records the incoming connection to the log. * @param conn a websocket connection object. * @param handshake a websocket handshake object. */ @Override public void onOpen(WebSocket conn, ClientHandshake handshake) { LOG.info(conn.getRemoteSocketAddress().getAddress().getHostAddress() + " entered the room!"); } /** * Removes a ConnectionInfo object associated with a given websocket connection from the * MetaInfoManager. * * @param conn a websocket connection object. * @param code an integer code that you can look up at * {@link org.java_websocket.framing.CloseFrame}. * @param reason an additional information. * @param remote Returns whether or not the closing of the connection was initiated by the remote * host. */ @Override public void onClose(WebSocket conn, int code, String reason, boolean remote) { LOG.info(conn + " has left the room!"); MetaInfoManager.ConnectionInfo connectionInfo = metaInfoManager.getConnectionInfo(conn); if (connectionInfo != null) { this.sendToClients(new ChatMessage(OutgoingMessage.MessageType.LEAVE, connectionInfo.getName(), connectionInfo.getRoom(), null)); metaInfoManager.removeConnection(conn); if (! updateAndSendParticipantListQueue.contains(connectionInfo.getRoom())) { updateAndSendParticipantListQueue.add(connectionInfo.getRoom()); } } } /** * Handles incoming messages. * * If the type of the incoming message is MessageType.ENTER, we need to check the username * against the current participant list and change the requested name with trailing underscores. * Regardless of the type, we invoke sendToClient method with every incoming messages. * * @param conn a websocket connection object. * @param rawMessage a raw message from the clients. */ @Override public void onMessage(WebSocket conn, String rawMessage) { // TODO: Make it threadsafe LOG.info(conn + ": " + rawMessage); ApiProxy.setEnvironmentForCurrentThread( ChatServerBridge.getInstance().getBackgroundEnvironment()); ChatMessage message = GSON.fromJson(rawMessage, ChatMessage.class); if (message.getType().equals(OutgoingMessage.MessageType.ENTER)) { // Check if there's a participant with the same name in the room. Set<String> participantSet = ChatRoomParticipants.getParticipants(message.getRoom()); if (participantSet.contains(message.getName())) { // Adding a trailing underscore until the conflict resolves. String newName = message.getName() + "_"; while (participantSet.contains(newName)) { newName = newName + "_"; } // New name decided. message = new ChatMessage(message.getType(), newName, message.getRoom(), message.getMessage()); ChatMessage systemMessage = new ChatMessage(OutgoingMessage.MessageType.SYSTEM, newName, message.getRoom(), "Changed the name to " + newName + "."); conn.send(GSON.toJson(systemMessage)); } metaInfoManager.addConnection(conn, message.getName(), message.getRoom()); if (! updateAndSendParticipantListQueue.contains(message.getRoom())) { updateAndSendParticipantListQueue.add(message.getRoom()); } } this.sendToClients(message); } /** * Just logs the exception. * @param conn a websocket connection object. * @param ex an exception. */ @Override public void onError(WebSocket conn, Exception ex) { LOG.warning(Throwables.getStackTraceAsString(ex)); } /** * Sends <var>message</var> to currently connected WebSocket clients in the same room as the * message. * * @param message An object representing a message to send across the network. */ public void sendToClients(OutgoingMessage message) { if (! message.getType().equals(OutgoingMessage.MessageType.PROPAGATE)) { propagateQueue.add(message); } else { ParticipantListMessage participantListMessage = GSON.fromJson(message.toJson(GSON), ParticipantListMessage.class); if (participantListMessage.getType().equals(OutgoingMessage.MessageType.PARTICIPANTS)) { LOG.info("ParticipantList arrived for the room:" + message.getRoom()); } } Collection<WebSocket> webSocketConnections = connections(); synchronized (webSocketConnections) { for (WebSocket connection : webSocketConnections) { MetaInfoManager.ConnectionInfo info = metaInfoManager.getConnectionInfo(connection); if (info != null) { if (message.shouldSendTo(info.getRoom())) { connection.send(message.toJson(GSON)); } } } } } }